#!/usr/bin/env python3
import os, sys, json, marshal, subprocess, tempfile, shutil, re, webbrowser, time, io, types, dis, zipfile


_lib = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'lib')
if os.path.isdir(_lib): sys.path.insert(0, _lib)

class C:
    P = '\033[95m'; W = '\033[97m'; B = '\033[1m'; D = '\033[2m'; I = '\033[3m'; R = '\033[0m'

if sys.platform == 'win32':
    try: import ctypes; kernel32 = ctypes.windll.kernel32; kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7)
    except: pass

BANNER = f"""{C.P}{C.B}
   ____       __  __                                      
  / __ \_  __/ / / /___ __________ ___  ____  ____  __  __
 / / / / |/_/ /_/ / __ `/ ___/ __ `__ \/ __ \/ __ \/ / / /
/ /_/ />  </ __  / /_/ / /  / / / / / / / /_/ / / / /_/ / 
\____/_/|_/_/ /_/\__,_/_/  /_/ /_/ /_/\____/_/ /_/\__, /  
                                                 /____/   
{C.R}
{C.P}{C.B}                     PyArmor Decoder Tool{C.R}
{C.P}{C.I}                     t.me/harmonyxbt{C.R}"""

KEYFILE = "universal_key.json"

def bk(t): return f"{C.P}[{C.R}{C.W}{t}{C.R}{C.P}]{C.R}"
def cls(): os.system('cls' if os.name == 'nt' else 'clear')
def wait(): input(f"\n  {bk('*')} Press {C.B}Enter{C.R} to continue...")
def spin(t, d=0.5):
    sys.stdout.write(f"  {C.P}>>>{C.R} {t}..."); sys.stdout.flush(); time.sleep(d)
    print(f"  {bk('OK')} {t}")
def sep(): print(f"  {C.P}{'-'*50}{C.R}")
def mline(t): print(f"  {C.P}|{C.R}  {t}")

DISROBE = None
def find_disrobe():
    global DISROBE
    cand = [os.environ.get("DISROBE_PATH",""), os.path.join(os.path.dirname(os.path.abspath(__file__)),"disrobe.exe"),"disrobe.exe"]
    try:
        for r,d,f in os.walk(tempfile.gettempdir()):
            for x in f:
                if x=="disrobe.exe": cand.append(os.path.join(r,x))
            break
    except: pass
    for c in cand:
        if c and os.path.isfile(c):
            try:
                r=subprocess.run([c,"--version"],capture_output=True,timeout=5)
                if r.returncode==0: DISROBE=c; return c
            except: continue
    return None

def check_deps():
    h={'disrobe':False,'uncompyle6':False}
    if find_disrobe(): h['disrobe']=True
    try: import uncompyle6; h['uncompyle6']=True
    except: pass
    return h

def load_key():
    kf=os.path.join(os.path.dirname(os.path.abspath(__file__)),KEYFILE)
    if os.path.isfile(kf):
        with open(kf) as f: return json.load(f)
    return None

def save_key(key,iv,runtime,serial):
    kf=os.path.join(os.path.dirname(os.path.abspath(__file__)),KEYFILE)
    data={'key_hex':key,'iv_hex':iv,'runtime':runtime,'serial':serial,'source':'disrobe'}
    old=load_key()
    if old and old.get('key_hex')==key:
        ivs=old.get('ivs',[]);
        if iv not in ivs: ivs.append(iv)
        data['ivs']=ivs
    else: data['ivs']=[iv]
    with open(kf,'w') as f: json.dump(data,f,indent=2)
    return data

def dump_key_from_runtime():
    """Auto-dump AES key from pyarmor_runtime.pyd by creating temp test file"""
    cls(); print(BANNER); sep(); mline(f"{C.B}AUTO KEY DUMP{C.R}"); sep()
    
    # Find runtime
    script_dir = os.path.dirname(os.path.abspath(__file__))
    rt_dirs = []
    for r,d,f in os.walk(script_dir):
        for x in f:
            if x.endswith('.pyd') and 'pyarmor' in x.lower():
                rt_dirs.append((r, os.path.join(r,x)))
                break
        if rt_dirs: break
    
    if not rt_dirs:
        print(f"\n  {bk('!')} No pyarmor_runtime.pyd found!")
        wait(); return None
    
    rt_dir, rt_path = rt_dirs[0]
    print(f"\n  Runtime: {rt_path}")
    
    d = find_disrobe()
    if not d:
        print(f"\n  {bk('!')} disrobe.exe not found!")
        wait(); return None
    
    tmp_dir = tempfile.mkdtemp()
    test_py = os.path.join(tmp_dir, 'tmp_test.py')
    output_dir = os.path.join(tmp_dir, 'out')
    
    with open(test_py, 'w') as f:
        f.write("x=1\nprint(x)\n")
    
    pyarmor_exe = None
    paths = [
        r'C:\Users\ADMIN\AppData\Local\Packages\PythonSoftwareFoundation.Python.3.12_qbz5n2kfra8p0\LocalCache\local-packages\Python312\Scripts\pyarmor.exe',
        os.path.join(os.path.dirname(sys.executable), 'Scripts', 'pyarmor.exe'),
    ]
    for p in paths:
        if os.path.isfile(p): pyarmor_exe = p; break
    if not pyarmor_exe:
        try:
            r = subprocess.run(['where', 'pyarmor'], capture_output=True, text=True, timeout=5)
            if r.returncode == 0 and r.stdout.strip(): pyarmor_exe = r.stdout.strip().split('\n')[0].strip()
        except: pass
    if not pyarmor_exe or not os.path.isfile(pyarmor_exe):
        print(f"\n  {bk('!')} PyArmor not found. Install: pip install pyarmor")
        shutil.rmtree(tmp_dir, ignore_errors=True); wait(); return None
    
    print(f"\n  Creating temp protected file...")
    try:
        r = subprocess.run([pyarmor_exe, 'gen', '-O', tmp_dir, test_py], capture_output=True, text=True, timeout=30, cwd=tmp_dir)
        if r.returncode != 0:
            print(f"  {bk('!')} Error: {r.stderr[-300:]}")
            shutil.rmtree(tmp_dir, ignore_errors=True)
            wait(); return None
    except Exception as e:
        print(f"  {bk('!')} Error: {e}")
        shutil.rmtree(tmp_dir, ignore_errors=True)
        wait(); return None
    
    prot_py = test_py
    
    if os.path.isfile(prot_py):
        with open(prot_py, 'rb') as fh:
            head = fh.read(500)
        if b'__pyarmor__' not in head:
            prot_py = None
            for root, dirs, files in os.walk(tmp_dir):
                for x in files:
                    if x.endswith('.py'):
                        fp = os.path.join(root, x)
                        with open(fp, 'rb') as fh:
                            h = fh.read(500)
                        if b'__pyarmor__' in h:
                            prot_py = fp
                            break
                if prot_py: break
    
    print(f"  Extracting key with disrobe...")
    try:
        r = subprocess.run([DISROBE, "pyarmor", "unpack", "--force", prot_py, "--out", output_dir], capture_output=True, text=True, timeout=30)
        if r.returncode == 0:
            mf = os.path.join(output_dir, 'manifest.json')
            if os.path.isfile(mf):
                with open(mf) as f: ki = json.load(f)
                key = ki.get('key_hex', '')
                iv = ki.get('iv_hex', '')
                serial = ki.get('serial', '')
                if key:
                    save_key(key, iv, rt_path, serial)
                    print(f"\n  {bk('OK')} AES Key: {C.B}{key}{C.R}")
                    print(f"  {bk('OK')} IV     : {iv}")
                    print(f"  {bk('OK')} Saved  : {KEYFILE}")
                    shutil.rmtree(tmp_dir, ignore_errors=True)
                    wait(); return key
        
        print(f"\n  {bk('!')} Key extraction failed.")
    except Exception as e:
        print(f"\n  {bk('!')} Error: {e}")
    
    shutil.rmtree(tmp_dir, ignore_errors=True)
    wait(); return None

# ====== PYARMOR BYTECODE DECOMPILER ======
class PyArmorDecompiler_0xHarmony:
    SKIP_OPS = {'NOP','CACHE','PUSH_EXC_INFO','POP_EXCEPT','RERAISE','COPY','EXTENDED_ARG','RESUME'}
    EXIT_MARKS = {'__pyarmor_exit_','__pyarmor_enter_','__pyarmor_assert_'}
    
    @staticmethod
    def _is_pyarmor(v):
        if isinstance(v,str) and any(p in v for p in PyArmorDecompiler_0xHarmony.EXIT_MARKS): return True
        if isinstance(v,bytes) and len(v)>4: return True
        return False
    
    @staticmethod
    def _clean(code_obj):
        try: insts = list(dis.get_instructions(code_obj))
        except: return []
        cleaned = []; i = 0
        while i < len(insts):
            op = insts[i].opname
            if op in PyArmorDecompiler_0xHarmony.SKIP_OPS: i+=1; continue
            if op=='PUSH_NULL' and i+1<len(insts) and insts[i+1].opname=='NOP':
                j=i
                while j<len(insts) and insts[j].opname in ('PUSH_NULL','NOP','JUMP_FORWARD','BUILD_TUPLE','CALL_FUNCTION_EX','POP_TOP','RETURN_VALUE'): j+=1
                if j>i+2: i=j; continue
            if op=='LOAD_CONST' and PyArmorDecompiler_0xHarmony._is_pyarmor(insts[i].argval):
                j=i
                while j<len(insts) and insts[j].opname not in ('POP_TOP','RERAISE','RETURN_VALUE'): j+=1
                if j<len(insts): j+=1
                i=j; continue
            if op in ('PUSH_NULL','KW_NAMES'): i+=1; continue
            if op=='UNPACK_SEQUENCE' and i+1<len(insts) and insts[i+1].opname=='CALL': i+=1; continue
            cleaned.append(insts[i]); i+=1
        return cleaned
    
    @staticmethod
    def _cname(n):
        return n.split('+ ')[-1].strip() if isinstance(n,str) and 'NULL|self + ' in n else n
    
    @staticmethod
    def _trans(insts, indent=0):
        pad='    '*indent; lines=[]; stack=[]; i=0
        while i<len(insts):
            op=insts[i].opname; arg=insts[i].arg; val=insts[i].argval
            if op=='LOAD_CONST':
                if isinstance(val,types.CodeType): stack.append(f'<fn:{val.co_name}>')
                elif isinstance(val,str): stack.append(repr(val))
                elif isinstance(val,bytes): pass
                elif val is None: stack.append('None')
                elif val is True: stack.append('True')
                elif val is False: stack.append('False')
                elif isinstance(val,(int,float)): stack.append(repr(val))
                elif isinstance(val,tuple):
                    items=[repr(v) if not isinstance(v,types.CodeType) else f'<fn:{v.co_name}>' for v in val]
                    stack.append(f"({', '.join(items)})")
                else: stack.append(repr(val))
            elif op=='LOAD_FAST': stack.append(val)
            elif op in ('LOAD_GLOBAL','LOAD_NAME'): stack.append(PyArmorDecompiler_0xHarmony._cname(val))
            elif op=='LOAD_ATTR':
                if stack and not str(stack[-1]).startswith('<fn:'):
                    stack[-1]=f'{stack[-1]}.{PyArmorDecompiler_0xHarmony._cname(val)}'
            elif op=='STORE_FAST':
                if stack: lines.append(f'{pad}{val}={stack.pop()}')
            elif op=='STORE_NAME':
                if stack: lines.append(f'{pad}{val}={stack.pop()}')
            elif op=='IMPORT_NAME': stack.append(val)
            elif op=='IMPORT_FROM':
                if stack:
                    lines.append(f'{pad}from {stack[-1]} import {PyArmorDecompiler_0xHarmony._cname(val)}')
                    stack.append(PyArmorDecompiler_0xHarmony._cname(val))
            elif op=='CALL':
                nargs=arg if arg else 0; al=[]
                for _ in range(min(nargs,len(stack))):
                    a=stack.pop()
                    if type(a)!=str or not a.startswith('<fn:'): al.insert(0,a)
                fn=stack.pop() if stack else '?'
                if fn and 'pyarmor' not in str(fn).lower() and str(fn)!='None' and str(fn)!='?':
                    lines.append(f'{pad}{fn}({", ".join(al)})')
            elif op in ('CALL_FUNCTION','CALL_METHOD'):
                nargs=arg if arg else 0; al=[]
                for _ in range(min(nargs,len(stack))):
                    a=stack.pop()
                    if type(a)!=str or not a.startswith('<fn:'): al.insert(0,a)
                fn=stack.pop() if stack else '?'
                if str(fn)!='None' and str(fn)!='?':
                    lines.append(f'{pad}{fn}({", ".join(al)})')
            elif op=='RETURN_VALUE': lines.append(f'{pad}return {stack.pop() if stack else "None"}')
            elif op=='POP_TOP':
                if stack:
                    v=stack.pop()
                    if isinstance(v,str) and not v.startswith('<fn:') and not v.startswith("b'") and 'pyarmor' not in v.lower(): lines.append(f'{pad}{v}')
            elif op=='BINARY_OP':
                if len(stack)>=2:
                    r=stack.pop(); l=stack.pop()
                    sym={0:'+',1:'-',2:'*',3:'/',4:'//',5:'%',6:'**'}.get(arg,'?')
                    stack.append(f'({l}{sym}{r})')
            elif op=='COMPARE_OP':
                if len(stack)>=2: r=stack.pop(); l=stack.pop(); stack.append(f'({l}{val}{r})')
            elif op=='BINARY_SUBSCR':
                if len(stack)>=2: k=stack.pop(); o=stack.pop(); stack.append(f'{o}[{k}]')
            elif op=='BUILD_TUPLE':
                n=arg if arg else 0; items=[]
                for _ in range(min(n,len(stack))):
                    x=stack.pop()
                    if type(x)!=str or not x.startswith('<fn:'): items.insert(0,x)
                if items: stack.append(f"({', '.join(items)})")
            elif op=='BUILD_LIST':
                n=arg if arg else 0; items=[]
                for _ in range(min(n,len(stack))):
                    x=stack.pop()
                    if type(x)!=str or not x.startswith('<fn:'): items.insert(0,x)
                if items: stack.append(f"[{', '.join(items)}]")
            elif op=='BUILD_STRING':
                n=arg if arg else 0; items=[]
                for _ in range(min(n,len(stack))): items.insert(0,stack.pop())
                if items: stack.append('f"'+''.join(items)+'"')
            elif op=='FORMAT_VALUE':
                if stack: stack.append(f'{{{stack.pop()}}}')
            elif op in ('MAKE_FUNCTION','GET_ITER','END_FOR','JUMP_FORWARD','JUMP_BACKWARD','DICT_MERGE','BUILD_MAP','MAP_ADD','LIST_APPEND','LOAD_BUILD_CLASS'): pass
            elif op=='FOR_ITER':
                if stack:
                    var_name = val
                    if i+1 < len(insts) and insts[i+1].opname in ('STORE_FAST','STORE_NAME'):
                        var_name = insts[i+1].argval
                    elif i+1 < len(insts):
                        var_name = f'_iter_{insts[i+1].argval}'
                    lines.append(f'{pad}for {var_name} in {stack[-1]}:')
            elif op in ('POP_JUMP_IF_FALSE','POP_JUMP_FORWARD_IF_FALSE'):
                if stack: lines.append(f'{pad}if not {stack.pop()}:')
            elif op=='POP_JUMP_IF_TRUE':
                if stack: lines.append(f'{pad}if {stack.pop()}:')
            elif op=='YIELD_VALUE': lines.append(f'{pad}yield {stack.pop() if stack else "None"}')
            elif op=='CONTAINS_OP':
                if len(stack)>=2: r=stack.pop(); l=stack.pop(); stack.append(f'({l} in {r})')
            elif op=='DELETE_SUBSCR':
                if len(stack)>=2: k=stack.pop(); o=stack.pop(); lines.append(f'{pad}del {o}[{k}]')
            elif op=='UNPACK_SEQUENCE':
                if stack:
                    v=stack[-1]
                    if isinstance(v,str) and v.startswith('('): stack.pop()
                    else:
                        vl=[f'v{j}' for j in range(min(arg if arg else 2,10))]
                        lines.append(f'{pad}{", ".join(vl)}={stack.pop()}')
            i+=1
        return '\n'.join(lines)
    
    @staticmethod
    def _parse_listcomp(code_obj):
        """Try to reconstruct listcomp/genexpr as comment from bytecode"""
        try:
            bc=code_obj.co_code
            consts=code_obj.co_consts
            names=code_obj.co_names
            varnames=code_obj.co_varnames
            name=code_obj.co_name
            if not bc: return None
            # Decode wordcode (2-byte per instruction)
            insts=[]
            i=0
            while i+1<len(bc):
                op=bc[i]; arg=bc[i+1]
                insts.append((op,arg,i))
                i+=2
            # Analyze pattern
            idx=0
            # Skip RESUME (op 0x97=151)
            if idx<len(insts) and insts[idx][0]==151: idx+=1
            # BUILD_LIST (op 0x67=103)
            if idx<len(insts) and insts[idx][0]==103: idx+=1
            # LOAD_FAST .0 (op 0x7c=124, arg 0)
            if idx<len(insts) and insts[idx][0]==124 and insts[idx][1]==0: idx+=1
            # FOR_ITER (op 0x5d=93)
            if idx<len(insts) and insts[idx][0]==93:
                idx+=1  # skip FOR_ITER
                idx+=1  # skip CACHE
            else: return None
            # Expression part
            expr_parts=[]
            while idx<len(insts):
                op,arg,off=insts[idx]
                if op==93:  # FOR_ITER (end loop check)
                    break
                elif op==124 and arg==1:  # LOAD_FAST _var
                    vname=varnames[arg] if arg<len(varnames) else f'v{arg}'
                    expr_parts.append(vname)
                elif op==100:  # LOAD_CONST
                    if arg<len(consts):
                        c=consts[arg]
                        if isinstance(c,str): expr_parts.append(repr(c))
                        else: expr_parts.append(repr(c))
                    else: expr_parts.append(f'const[{arg}]')
                elif op==25:  # BINARY_SUBSCR
                    if len(expr_parts)>=2:
                        r=expr_parts.pop(); l=expr_parts.pop()
                        expr_parts.append(f'{l}[{r}]')
                    # skip caches (4 for BINARY_SUBSCR)
                    idx+=4
                elif op==107 and arg==2:  # COMPARE_OP ==
                    if len(expr_parts)>=2:
                        r=expr_parts.pop(); l=expr_parts.pop()
                        expr_parts.append(f'({l}=={r})')
                    idx+=2  # skip cache
                elif op==175:  # POP_JUMP_FORWARD_IF_FALSE
                    # This is the filter condition - mark it
                    if expr_parts:
                        expr_parts.insert(0,'if:')
                    idx+=0  # no extra skip
                elif op==145:  # LIST_APPEND
                    pass
                elif op==91:  # JUMP_BACKWARD or similar
                    pass
                elif op==83:  # RETURN_VALUE
                    pass
                elif op==0:  # CACHE
                    pass
                else:
                    expr_parts.append(f'${op}_{arg}')
                idx+=1
            
            expr=' '.join(expr_parts) if expr_parts else '?'
            # Determine the list comprehension form
            vn=varnames[1] if len(varnames)>1 else 'x'
            if 'if:' in expr:
                cond_part=expr.split('if:')[1].strip() if 'if:' in expr else ''
                result_part=expr.split('if:')[0].strip() if 'if:' in expr else expr
            else:
                result_part=expr
                cond_part=''
            if name=='<listcomp>':
                if cond_part:
                    return f'# [{result_part} for {vn} in .0 if {cond_part}]'
                else:
                    return f'# [{result_part} for {vn} in .0]'
            elif name=='<genexpr>':
                if cond_part:
                    return f'# ({result_part} for {vn} in .0 if {cond_part})'
                else:
                    return f'# ({result_part} for {vn} in .0)'
            return None
        except: return None
    
    @staticmethod
    def _raw_dump(code_obj, indent=0):
        """Full raw disassembly"""
        pad='    '*indent
        # Try comprehension analysis first
        try:
            comp=PyArmorDecompiler_0xHarmony._parse_listcomp(code_obj)
            if comp: return pad+comp
        except: pass
        # Try dis.dis first
        try:
            out=io.StringIO(); dis.dis(code_obj,file=out)
            raw=out.getvalue()
            if raw.strip():
                raw_lines=[l for l in raw.split('\n') if l.strip()]
                return pad+'# bytecode:\n'+'\n'.join(pad+'  '+l for l in raw_lines)
        except: pass
        # Fallback: hex dump with metadata
        try:
            bc=code_obj.co_code
            if bc:
                lines=[pad+'# bytecode (hex):']
                for off in range(0,len(bc),16):
                    chunk=bc[off:off+16]
                    hex_str=' '.join(f'{b:02x}' for b in chunk)
                    ascii_str=''.join(chr(b) if 32<=b<127 else '.' for b in chunk)
                    lines.append(pad+f'  {off:04x}: {hex_str:48s} {ascii_str}')
                if code_obj.co_consts:
                    lines.append(pad+f'  # consts={code_obj.co_consts!r}')
                if code_obj.co_names:
                    lines.append(pad+f'  # names={code_obj.co_names!r}')
                if code_obj.co_varnames:
                    lines.append(pad+f'  # varnames={code_obj.co_varnames!r}')
                return '\n'.join(lines)
        except: pass
        return pad+'# (empty)'
    
    @staticmethod
    def decompile(code_obj, indent=0):
        insts=PyArmorDecompiler_0xHarmony._clean(code_obj)
        if not insts:
            return PyArmorDecompiler_0xHarmony._raw_dump(code_obj, indent)
        return PyArmorDecompiler_0xHarmony._trans(insts, indent)
    
    @staticmethod
    def extract_funcs(code_obj):
        funcs=[]
        for c in code_obj.co_consts:
            if isinstance(c,types.CodeType) and c.co_name!='<module>':
                funcs.append(c); funcs.extend(PyArmorDecompiler_0xHarmony.extract_funcs(c))
        return funcs
    
    @staticmethod
    def get_source(pyc_path):
        with open(pyc_path,'rb') as f: magic=f.read(4)
        mi=int.from_bytes(magic[:2],'little')
        hs=16
        with open(pyc_path,'rb') as f: f.read(hs); code=marshal.load(f)
        lines=[f"# Decompiled by PyArmor Decoder v2 | 0xHarmony",f"# Source: {getattr(code,'co_filename','unknown')}",""]
        mod_src=PyArmorDecompiler_0xHarmony.decompile(code)
        if mod_src.strip(): lines.append(mod_src)
        for func in PyArmorDecompiler_0xHarmony.extract_funcs(code):
            args=', '.join(func.co_varnames[:func.co_argcount])
            cname=func.co_name
            lines.append(f"\ndef {cname}({args}):")
            try:
                fs=PyArmorDecompiler_0xHarmony.decompile(func,1)
                lines.append(fs if fs.strip() else "    pass")
            except Exception as e: lines.append(f"    # error: {e}"); lines.append("    pass")
        return '\n'.join(lines)

# ====== MENU FUNCTIONS ======
def analyze_file(f):
    with open(f,'rb') as fh: c=fh.read()
    i={'path':f,'name':os.path.basename(f),'size':len(c),'marker':None,'runtime':None,'runtime_pyd':None}
    for m in [b'PY000000',b'PYARMOR']:
        if m in c: i['marker']=m.decode(); break
    mt=re.search(rb"from\s+(\S+)\s+import\s+__pyarmor__",c)
    if mt: i['runtime']=mt.group(1).decode()
    b=os.path.dirname(os.path.abspath(f))
    if i['runtime']:
        rd=os.path.join(b,i['runtime'])
        if os.path.isdir(rd):
            for x in os.listdir(rd):
                if x.endswith('.pyd') or x.endswith('.dll'): i['runtime_pyd']=os.path.join(rd,x)
    return i

def process_results(out_dir):
    mf=os.path.join(out_dir,'manifest.json')
    pyc_files=[f for f in os.listdir(out_dir) if f.endswith('.pyc')]
    print(f"\n  {C.P}{'='*50}{C.R}")
    print(f"  {C.B}RESULTS{C.R}")
    print(f"  {C.P}{'='*50}{C.R}")
    if os.path.isfile(mf):
        with open(mf) as f: m=json.load(f)
        print(f"\n  {C.P}|{C.R}  PyArmor    : {m.get('detection','?')} {m.get('version','?')}")
        print(f"  {C.P}|{C.R}  Target Py  : {m.get('python','?')}")
        print(f"  {C.P}|{C.R}  Serial     : {m.get('serial','?')}")
        print(f"  {C.P}|{C.R}  KEY (AES)  : {C.B}{m.get('key_hex','?')}{C.R}")
        print(f"  {C.P}|{C.R}  IV         : {m.get('iv_hex','?')}")
        print(f"  {C.P}|{C.R}  Plaintext  : {m.get('plaintext_size',0)} bytes")
    for pyc in sorted(pyc_files):
        pyc_path=os.path.join(out_dir,pyc); py_out=os.path.join(out_dir,pyc.replace('.pyc','.py'))
        print(f"\n  {C.P}|{C.R}  Processing: {pyc}")
        try:
            src=PyArmorDecompiler_0xHarmony.get_source(pyc_path)
            if src.strip():
                with open(py_out,'w',encoding='utf-8') as f: f.write(src)
                print(f"  {C.P}|{C.R}  {bk('OK')} Decompiled -> {C.B}{py_out}{C.R} ({src.count(chr(10))} lines)")
        except Exception as e:
            print(f"  {C.P}|{C.R}  {bk('!')} Error: {e}")

def decode_menu():
    while True:
        cls(); print(BANNER); sep(); mline(f"{C.B}DECODE FILE{C.R}"); sep()
        d=find_disrobe()
        if not d:
            print(f"\n  {bk('!')} disrobe.exe not found!"); wait(); return
        pf=[]; seen=set()
        for dd in [os.getcwd(),os.path.dirname(os.path.abspath(__file__))]:
            if os.path.isdir(dd):
                for f in sorted(os.listdir(dd)):
                    if f.endswith('.py') and f!=os.path.basename(__file__):
                        fp=os.path.normpath(os.path.join(dd,f))
                        if fp.lower() not in seen: seen.add(fp.lower()); pf.append(fp)
        if pf:
            for i,f in enumerate(pf[:20],1):
                s=os.path.getsize(f); lb=""
                try:
                    with open(f,'rb') as fh: c=fh.read(200)
                    if b'PY000000' in c or b'__pyarmor__' in c: lb=f" {C.P}[PyArmor]{C.R}"
                except: pass
                print(f"  {bk(str(i))} {os.path.basename(f)} ({s:,} bytes){lb}")
            print(f"\n  {bk('0')} Back\n  {bk('p')} Manual path\n")
            p=input(f"  {C.P}[?]{C.R} Choose: ").strip()
            if p=="0": break
            elif p.lower()=='p':
                pm=input(f"\n  {C.P}[?]{C.R} Path: ").strip().strip('"').strip("'")
                if pm and os.path.isfile(pm): pf=[pm]; p="1"
                else: print(f"\n  {bk('!')} Not found!"); wait(); continue
            elif p.isdigit() and 1<=int(p)<=len(pf):
                idx=int(p)-1; fp=pf[idx]; bn=os.path.splitext(os.path.basename(fp))[0]; od=os.path.join(os.path.dirname(fp),f"decoded_{bn}")
                cls(); print(BANNER); sep(); mline(f"DECODING: {os.path.basename(fp)}"); sep()
                print(f"\n  {bk('*')} Starting...\n")
                spin(f"Analyzing {os.path.basename(fp)}",0.3)
                meta=analyze_file(fp)
                print(f"\n  {C.P}|{C.R}  File: {meta['name']} ({meta['size']:,} bytes)")
                print(f"  {C.P}|{C.R}  Runtime: {meta['runtime'] or 'N/A'}")
                if not meta['marker']: print(f"\n  {bk('!')} Not PyArmor protected!"); wait(); continue
                spin("Running disrobe",0.5)
                os.makedirs(od,exist_ok=True)
                try:
                    r=subprocess.run([d,"pyarmor","unpack","--force",fp,"--out",od],capture_output=True,text=True,timeout=120)
                    if r.returncode!=0: print(f"\n  {bk('!')} disrobe failed: {r.stderr[:300]}"); wait(); continue
                    print(f"\n  {bk('OK')} disrobe done!\n")
                    process_results(od)
                    mf_path=os.path.join(od,'manifest.json')
                    if os.path.isfile(mf_path):
                        with open(mf_path) as f: ki=json.load(f)
                        if ki.get('key_hex'): save_key(ki['key_hex'],ki['iv_hex'],meta.get('runtime_pyd',''),ki.get('serial',''))
                        print(f"\n  {bk('OK')} Universal key saved to {C.B}{KEYFILE}{C.R}")
                except subprocess.TimeoutExpired: print(f"\n  {bk('!')} Timeout!")
                except Exception as e: print(f"\n  {bk('!')} Error: {e}")
                wait()
            else: print(f"\n  {bk('!')} Invalid!"); wait()
        else:
            print(f"\n  {bk('!')} No .py files.\n")
            pm=input(f"  {C.P}[?]{C.R} Path (0=back): ").strip().strip('"').strip("'")
            if pm=="0": break
            elif pm and os.path.isfile(pm):
                bn=os.path.splitext(os.path.basename(pm))[0]; od=os.path.join(os.path.dirname(pm),f"decoded_{bn}")
                cls(); print(BANNER); sep(); mline(f"DECODING: {os.path.basename(pm)}"); sep()
                print(f"\n  {bk('*')} Starting...\n")
                spin(f"Analyzing {os.path.basename(pm)}",0.3)
                meta=analyze_file(pm)
                print(f"\n  {C.P}|{C.R}  File: {meta['name']} ({meta['size']:,} bytes)")
                print(f"  {C.P}|{C.R}  Runtime: {meta['runtime'] or 'N/A'}")
                if not meta['marker']: print(f"\n  {bk('!')} Not PyArmor protected!"); wait(); continue
                spin("Running disrobe",0.5)
                os.makedirs(od,exist_ok=True)
                try:
                    r=subprocess.run([d,"pyarmor","unpack","--force",pm,"--out",od],capture_output=True,text=True,timeout=120)
                    if r.returncode!=0: print(f"\n  {bk('!')} disrobe failed: {r.stderr[:300]}"); wait(); continue
                    print(f"\n  {bk('OK')} disrobe done!\n")
                    process_results(od)
                    mf_path=os.path.join(od,'manifest.json')
                    if os.path.isfile(mf_path):
                        with open(mf_path) as f: ki=json.load(f)
                        if ki.get('key_hex'): save_key(ki['key_hex'],ki['iv_hex'],meta.get('runtime_pyd',''),ki.get('serial',''))
                        print(f"\n  {bk('OK')} Universal key saved to {C.B}{KEYFILE}{C.R}")
                except subprocess.TimeoutExpired: print(f"\n  {bk('!')} Timeout!")
                except Exception as e: print(f"\n  {bk('!')} Error: {e}")
                wait()

def main():
    while True:
        cls(); print(BANNER); sep()
        d=check_deps(); uk=load_key()
        sd=f"{C.P}OK{C.R}" if d['disrobe'] else f"{C.P}NO{C.R}"
        su=f"{C.P}OK{C.R}" if d['uncompyle6'] else f"{C.P}NO{C.R}"
        sk=f"{C.P}OK{C.R}" if uk else f"{C.P}NO{C.R}"
        print(f"  {C.P}|{C.R}  disrobe [{sd}]  |  uncompyle6 [{su}]  |  key [{sk}]")
        if uk: print(f"  {C.P}|{C.R}  AES Key: {C.B}{uk.get('key_hex','')}{C.R}")
        else: print(f"  {C.P}|{C.R}  AES Key: (not found - use [3] Dump Key)")
        sep()
        print(f"  {C.P}|{C.R}  {C.B}[1]{C.R} Install Packages")
        print(f"  {C.P}|{C.R}  {C.B}[2]{C.R} Decode File")
        print(f"  {C.P}|{C.R}  {C.B}[3]{C.R} Dump Key from Runtime")
        print(f"  {C.P}|{C.R}  {C.B}[0]{C.R} Exit")
        sep()
        p=input(f"\n  {C.P}[?]{C.R} Choose: ").strip()
        if p=="1":
            cls(); print(BANNER); sep(); mline("INSTALL PACKAGES"); sep()
            print(f"\n  1. Install uncompyle6")
            print(f"  2. Download disrobe.exe")
            print(f"  0. Back")
            p2=input(f"\n  {C.P}[?]{C.R} Choose: ").strip()
            if p2=="1":
                print()
                r=subprocess.run([sys.executable,"-m","pip","install","uncompyle6"],capture_output=True,text=True)
                if r.returncode==0: print(f"  {bk('OK')} Success!")
                else: print(f"  {bk('NO')} Failed: {r.stderr[-200:]}")
                wait()
            elif p2=="2":
                webbrowser.open("https://github.com/1-3-7/disrobe/releases")
                print(f"\n  Download & extract disrobe.exe to tool folder.")
                wait()
        elif p=="2": decode_menu()
        elif p=="3": dump_key_from_runtime()
        elif p=="0":
            print(f"\n  {C.P}[*]{C.R} Bye! {C.I}t.me/harmonyxbt{C.R}\n"); sys.exit(0)
        else: print(f"\n  {bk('!')} Invalid!"); time.sleep(1)

if __name__ == "__main__":
    main()
